How to handle localized strings from a separate bundle or framework in SwiftUI

SwiftUI provides LocalizedStringKey that can be used to initialize several View types, such as Text, Toggle, and others. It works differently from UIKit in that regard, as if the localized strings leave in some other bundle than the main one from where by default SwiftUI tries to get the values, you have to pass that information to the Text constructor) (not to LocalizedStringKey), in contrast to NSLocalizedString.

It is connected to the fact that SwiftUI internally handles the possibility to easily override the locale used by the view.

view.environment(\.locale, .init(identifier: "pl"))

It is very handy, especially in the case of the SwiftUI Previews.

ForEach(localizations, id: \.identifier) { locale in
    Text("Hello")
        .environment(\.locale, locale)
        .previewDisplayName(Locale.current.localizedString(forIdentifier: locale.identifier))
}

The problem is when you would like to use NSLocalizedString, or you have a custom implementation for the localized strings handling. Then most probably you will lose the ability to set the locale by .environment(\.locale, locale) on the views because it won't be respected. As the environment value is available in the View context and Locale.current stays the same across the app. There is no way to get the locale set for the given view and its child views outside of the body.

To overcome that issue I came up with a solution that allows to override of the locale environment and provide a custom implementation of the localized strings.

struct LocalizedText: View {
    @Environment(\.locale) var locale
    let key: String
    let localizedString: (_ languageCode: String?, _ key: String) -> (String)

    var body: Text {
        let languageCode = locale.languageCode ?? Locale.current.languageCode
        let localizedString = localizedString(languageCode, key)

        return Text(localizedString)
    }
}

LocalizedText view is used to get the locale from the environment as it is only available from the View context. Using localizedString closure, which gets all the information needed to resolve the localized string which is the key and languageCode (if needed the whole Locale object could be passed instead).

Example of use could look like this:

extension L18n {
    static func localizedString(_ key: String, languageCode: String) -> String {
        NSLocalizedString(
            key,
            tableName: "",
            bundle: resolveBundle(for : languageCode),
            value: "**\(key)**",
            comment: ""
        )
    }

    static func localizedText(_ key: String) -> some View {
        LocalizedText(key: key, localizedString: { languageCode, key in
            localizedString(key, languageCode: languageCode)
        })
    }
}
struct SomeView: View {
    var body: some View {
        L18n.localizedText("Hello")
    }
}

struct SomeView_Preview: PreviewProvider {
    static var previews: some View {
        SomeView()
            .environment(\.locale, Locale(identifier: "pl"))
    }
}

This way we keep the best of both worlds. Especially do not lose the ability of SwiftUI EnvironmentValues and still provide a custom implementation for the localized strings handling. We can keep *.lproj and *.strings files in a separate framework, and provide a custom implementation of the localized strings by e.g. having a complicated fallback translations business logic in case the key is missing for the asked language.

Tagged with: